import { normalizePath } from '@rollup/pluginutils'
import { FSWatcher, watch } from 'chokidar'
import fs, { existsSync, PathLike, readFile, unlink } from 'fs-extra'
import { stat } from 'fs/promises'
import { once, uniqueId } from 'lodash-es'
import MagicString from 'magic-string'
import { lookup } from 'mime-types'
import path from 'path'
import copy from 'recursive-copy'
import { InternalModuleFormat, Plugin, PluginContext } from 'rollup'
import through from 'through2'
import { COMMONJS_REQUIRE, CWD, isPreprocessorType, JS_TYPES_RE } from './constants'
import { compressors } from './minify'
import { resolveOptions } from './options'
import { UserOptions } from './types'
import { getRelativeUrlFromDocument, getResolveUrl, isVirtual, toBuffer } from './utils'

export { DEFAULT_ASSETS_INCLUDE_RE as defaultInclude } from './constants'
export { Compressor, UserOptions } from './types'

const AssetTypes = ['url', 'inline', 'raw', 'fork', 'worker', 'sharedworker'] as const
type ASSET_TYPE = typeof AssetTypes[number]
const SUFFIX_RE = new RegExp(`^(.+)\\?(${AssetTypes.join('|')})$`)

const DYNAMIC_FILE_URL_RE__GLOBAL =
  /(?<!\+)\s*(?:(?<![\w$])(?<d0>__dirname|__filename|import\.meta\.url)(?<d1>(?:\s*\+\s*(?:(?<q0>['"`])[^\n\r'"`\?]*?(?<!\\)\k<q0>)\s*)+)|`\$\{\s*(?<e0>__dirname|__filename|import\.meta\.url)\s*\}(?<e1>[^\n\r'"`]+)`|(?:[\w]+\.)*(?<f>join|resolve)\s*\(\s*(?<f0>__dirname|__filename|import\.meta\.url)(?<f1>(?:\s*,\s*(?<q1>['"`])[^\n\r'"`\?]*?(?<!\\)\k<q1>\s*)+),?\s*\))(?!\+)/g
const REQUIRE_RESOLVE_RE__GLOBAL =
  /(?<m>(?<!\.\s*)(?:require\s*(?:\.\s*resolve|\[\s*(?<q0>['"`])resolve\k<q0>\s*\])|\(\s*(?:(?:void\s+)?[\w\$]+\s*,\s*)*require\s*(?:\)\s*(?:\.\s*resolve|\[\s*(?<q1>['"`])resolve\k<q1>\s*\])|(?:\.\s*resolve|\[\s*(?<q2>['"`])resolve\k<q2>\s*\])\s*\)))\s*)(?<c>\((?:(?<q3>['"])[^\n\r'"\?]*?(?<!\\)\k<q3>)(?:\s*\+\s*(?:(?<q4>['"])[^\n\r'"\?]*?(?<!\\)\k<q4>)\s*)*\))/g

const PATH_PREFIX = '\0PATH:'
const NATIVE_PREFIX = '\0NATIVE:'
const NATIVE_SUFFIX = '.cjs'

const ASSET_RE =
  /(?<![\w$])__ROLLUP(?<nostring>__N-S)?__ASSETS__(?:(?<ref>[a-zA-Z\d]{8})|PUBLIC__(?<base64_fileName>[\w=+/]+))__(?![\w$])/
const ASSET_RE__GLOBAL = new RegExp(ASSET_RE, 'g')
const NODE_REQUIRE__GLOBAL = /(?<![\w$])__NODE__REQUIRE__(?![\w$])/g

const getStringifyAssetUrl = (ref: string, nostring = false) => {
  return `__ROLLUP${nostring ? '__N-S' : ''}__ASSETS__${ref}__`
}

const getStringifyPublicAssetUrl = (fileName: string, nostring = false) => {
  return `__ROLLUP${nostring ? '__N-S' : ''}__ASSETS__PUBLIC__${Buffer.from(fileName).toString('base64')}__`
}

export default function assets(options?: UserOptions): Plugin {
  const {
    publicPath,
    publicPathRe,
    commonFilter,
    filter,
    inline,
    minify,
    target,
    resolveFileNameExpression,
    base,
    native
  } = resolveOptions(options)

  let isWatchMode: boolean
  let sourcemap: boolean = true
  let srcRoot: string = ''
  let format: InternalModuleFormat

  const _readFile = async (filename: PathLike | number) => {
    if (Buffer.isBuffer(filename)) {
      filename = filename.toString()
    }
    let buffer: Buffer | string = await readFile(filename)
    if (typeof filename === 'string') {
      if (await minify(filename)) {
        const extname = path.extname(filename).replace(/^\./, '')
        try {
          buffer = (await compressors[extname](buffer, filename)) ?? buffer
        } catch {}
      }
    }
    return toBuffer(buffer)
  }

  let emitFilesMap: { [fileName: string]: string } = Object.create(null)

  const _emitAssets = async (_this: PluginContext, id: string) => {
    return (
      emitFilesMap[id] ??
      (emitFilesMap[id] = _this.emitFile({
        type: 'asset',
        name: PATH_PREFIX + id,
        source: await _readFile(id)
      }))
    )
  }

  const updateReferenceId = async (_this: PluginContext, fileNameOrId: string) => {
    _this.addWatchFile(fileNameOrId)
    if (filter(fileNameOrId) || isPreprocessorType(fileNameOrId)) {
      return await _emitAssets(_this, fileNameOrId)
    } else {
      try {
        await _this.load({ id: fileNameOrId })
        return (
          emitFilesMap[fileNameOrId] ??
          (emitFilesMap[fileNameOrId] = _this.emitFile({
            type: 'chunk',
            id: fileNameOrId
          }))
        )
      } catch (error) {
        return await _emitAssets(_this, fileNameOrId)
      }
    }
  }

  const publicAssetsToStringifyUrl = (
    _this: PluginContext,
    fileName: string,
    publicDir: string,
    nostring?: boolean
  ) => {
    fileName = path.normalize(fileName)
    const outPublicDir = publicPath[publicDir] ?? ''
    const outFileName = normalizePath(path.join(outPublicDir, path.relative(publicDir, fileName)))
    return getStringifyPublicAssetUrl(outFileName, nostring)
  }

  const generateStringifyAssetUrl = async (_this: PluginContext, fileName: string, nostring?: boolean) => {
    if ((await stat(fileName)).isDirectory()) {
      const dir = fileName
      const targetDir = normalizePath(path.relative(CWD, dir).replace(/:/g, '')).replace(/.+(?=\bnode_modules\b)/, '')

      extraPublicPaths.set(dir, targetDir)
      return getStringifyPublicAssetUrl(targetDir, nostring)
    }
    if (publicPathRe) {
      const match = fileName.match(publicPathRe)
      if (match) {
        return publicAssetsToStringifyUrl(_this, fileName, match[0], nostring)
      }
    }
    return getStringifyAssetUrl(await updateReferenceId(_this, fileName), nostring)
  }

  let extraPublicPaths = new Map<string, string>()

  const generateStringifyNativeUrl = async (_this: PluginContext, fileName: string) => {
    if (publicPathRe) {
      const match = fileName.match(publicPathRe)
      if (match) {
        return publicAssetsToStringifyUrl(_this, fileName, match[0])
      }
    }
    for (const [dir, targetDir] of extraPublicPaths) {
      if (fileName.startsWith(dir)) {
        return getStringifyPublicAssetUrl(normalizePath(path.posix.join(targetDir, path.relative(dir, fileName))))
      }
    }
    const dir = path.dirname(fileName)
    const targetDir = normalizePath(path.relative(CWD, dir).replace(/:/g, '')).replace(/.+(?=\bnode_modules\b)/, '')

    extraPublicPaths.set(dir, targetDir)
    return getStringifyPublicAssetUrl(normalizePath(path.posix.join(targetDir, path.relative(dir, fileName))))
  }

  const assetToStringifyUrl = async (_this: PluginContext, fileName: string, type: ASSET_TYPE) => {
    const getStringifyFileUrl = async () => {
      return generateStringifyAssetUrl(_this, fileName)
    }

    switch (type) {
      case 'raw': {
        _this.addWatchFile(fileName)
        const data = (await readFile(fileName)).toString()
        return `export default ${JSON.stringify(data)};`
      }
      case 'inline': {
        _this.addWatchFile(fileName)
        const contentType = lookup(fileName.replace(JS_TYPES_RE, '.js'))
        const base64 = toBuffer(await _readFile(fileName)).toString('base64')
        const data = `data:${contentType || ''};base64,${base64}`
        return `export default ${JSON.stringify(data)};`
      }
      case 'url':
        return `export default ${await getStringifyFileUrl()};`
      case 'fork':
        return `import {fork} from 'child_process';export default fork.bind(undefined,${await getStringifyFileUrl()});`
      case 'worker':
        return (
          (target === 'node' ? `import {Worker as _Worker} from 'worker_threads';` : `const _Worker = Worker;`) +
          `const workerPath = ${await getStringifyFileUrl()};const MyWorker = class Worker extends _Worker {constructor(options) {super(workerPath, options);}};export default MyWorker;`
        )
      case 'sharedworker':
        return `const workerPath = ${await getStringifyFileUrl()};const MyWorker = class Worker extends SharedWorker {constructor(options) {super(workerPath, options);}};export default MyWorker;`
    }
  }

  const _copyOptions = {
    overwrite: true,
    dot: true,
    filter: commonFilter,
    transform: (src: string, dest: string, stats: fs.Stats) => {
      return through(async (chunk, enc, done) => {
        if (await minify(src)) {
          const extname = path.extname(src).replace(/^\./, '')
          try {
            chunk = (await compressors[extname](chunk, src)) ?? chunk
          } catch {}
        }
        done(null, chunk)
      })
    }
  }

  const copyPublics = once(async (outdir: string) => {
    await Promise.all(
      Object.entries(publicPath).map(async ([src, dest]) => {
        dest = path.join(outdir, dest)

        try {
          await copy(src, dest, _copyOptions)
          if (isWatchMode) {
            return new Promise<FSWatcher>((resolve, reject) => {
              const watcher = watch('**/*', {
                cwd: src,
                followSymlinks: false,
                ignorePermissionErrors: true,
                awaitWriteFinish: {
                  stabilityThreshold: 1000,
                  pollInterval: 250
                },
                alwaysStat: true,
                atomic: true
              }) as FSWatcher

              watcher.on('ready', () => {
                resolve(watcher)
              })

              watcher.on('add', file => {
                copy(path.join(src, file), path.join(dest, file), _copyOptions)
              })

              watcher.on('change', file => {
                copy(path.join(src, file), path.join(dest, file), _copyOptions)
              })

              watcher.on('unlink', file => {
                unlink(path.join(dest, file))
              })
            })
          }
        } catch (err) {
          console.error(err)
        }
      })
    )
  })

  const resolveFileUrl = (fileName: string, chunkFileName: string) => {
    if (base) {
      let url = path.posix.join(base, fileName)
      if (!url.startsWith('/') && !/^\.[\.\/]/.test(url) && !url.includes(':')) {
        url = './' + url
      }
      return JSON.stringify(url)
    } else {
      let relativePath = path.posix.relative(path.posix.dirname(chunkFileName), fileName)

      switch (format) {
        case 'amd':
          if (/^\.[\/\\\.]/.test(relativePath)) relativePath = './' + relativePath
          return getResolveUrl(`require.toUrl(${JSON.stringify(relativePath)}), document.baseURI`)
        case 'cjs':
          if (target === 'node') {
            return `(__dirname + ${JSON.stringify('/' + relativePath)})`
          } else {
            return `(typeof document === 'undefined' ? ${getResolveUrl(
              `'file:' + __dirname + ${JSON.stringify('/' + relativePath)}`,
              `(require('u' + 'rl').URL)`
            )} : ${getRelativeUrlFromDocument(relativePath)})`
          }
        case 'es':
          if (target === 'node') {
            return `(import.meta.url.replace('file:///', '') + ${JSON.stringify('/../' + relativePath)})`
          } else {
            return getResolveUrl(`${JSON.stringify(relativePath)}, import.meta.url`)
          }
        case 'iife':
          return getRelativeUrlFromDocument(relativePath)
        case 'system':
          return getResolveUrl(`${JSON.stringify(relativePath)}, module.meta.url`)
        case 'umd':
          if (target === 'node') {
            return `(__dirname + ${JSON.stringify('/' + relativePath)})`
          } else {
            return `(typeof document === 'undefined' && typeof location === 'undefined' ? ${getResolveUrl(
              `'file:' + __dirname + ${JSON.stringify('/' + relativePath)}`,
              `(require('u' + 'rl').URL)`
            )} : ${getRelativeUrlFromDocument(relativePath, true)})`
          }
      }
    }
  }

  const plugin: Plugin = {
    name: 'assets',
    options() {
      isWatchMode = this.meta.watchMode
      return null
    },
    outputOptions(options) {
      const { assetFileNames = 'assets/[name]-[hash][extname]' } = options
      options.dir = options.dir ?? 'dist'
      options.assetFileNames = function (this: any, asset) {
        let { name } = asset

        if (name) {
          if (name.startsWith(PATH_PREFIX)) {
            name = name.slice(PATH_PREFIX.length)
          }

          const isVirt = isVirtual(name)

          if (srcRoot && !isVirt) {
            return normalizePath(path.relative(srcRoot, name))
          }

          if (path.isAbsolute(name)) {
            name = path.basename(name)
          } else if (!isVirt) {
            name = normalizePath(name)
          }
        } else {
          name = uniqueId('asset-')
        }

        asset.name = name

        const extname = name.match(/(\.\w+)+$/)?.[0] || path.extname(name)
        name = name.slice(0, name.length - extname.length)

        return typeof assetFileNames === 'string'
          ? assetFileNames.replace(/\[name\]/g, name).replace(/\[extname\]/g, extname)
          : assetFileNames.call(this, asset)
      }
      return null
    },
    async renderStart(options) {
      options.preserveModules &&
        options.preserveModulesRoot &&
        (srcRoot = path.resolve(CWD, options.preserveModulesRoot))
      sourcemap = !!options.sourcemap
      format = options.format
    },
    async generateBundle(options) {
      emitFilesMap = Object.create(null)
      await copyPublics(path.resolve(CWD, options.dir ?? 'dist'))

      let _extraPublicPaths = Array.from(extraPublicPaths).sort((a, b) => a[0].length - b[0].length)
      extraPublicPaths.clear()

      _extraPublicPaths = _extraPublicPaths.filter(([curr_dir]) => {
        for (const [dir] of _extraPublicPaths) {
          if (curr_dir !== dir && curr_dir.startsWith(dir)) {
            return false
          }
        }
        return true
      })

      const outdir = path.resolve(CWD, options.dir ?? 'dist')

      await Promise.all(
        _extraPublicPaths.map(async ([src, dest]) => {
          dest = path.join(outdir, dest)

          try {
            await copy(src, dest, _copyOptions)
          } catch (err) {
            console.error(err)
          }
        })
      )
    },
    async resolveId(source, importer, options) {
      let match

      if (source.endsWith(COMMONJS_REQUIRE)) {
        source = source.slice(0, source.length - COMMONJS_REQUIRE.length)
      }

      if (ASSET_RE.test(source)) return false

      if (source.endsWith('.node') && !/[\?\0]/.test(source)) {
        if (importer && /^\.[\/\\\.]/.test(source)) {
          source = path.join(path.dirname(importer), source)
        } else if (source.startsWith('/')) {
          return
        }

        if (!path.isAbsolute(source)) return
        source = path.normalize(source)

        const newId = NATIVE_PREFIX + source + NATIVE_SUFFIX
        return newId
      } else if (source.startsWith(NATIVE_PREFIX) && source.endsWith(NATIVE_SUFFIX)) {
        return source
      } else if ((match = source.match(SUFFIX_RE))) {
        let [, id, type] = match
        const m = await this.resolve(id, importer, { ...options, skipSelf: true })
        if (m) {
          if (m.external) {
            throw new Error(`External module ${JSON.stringify(id)} cannot be treated as assets.`)
          }
          id = m.id
        }

        if (/[\?\0]/.test(id)) return null

        if (!path.isAbsolute(id)) {
          if (importer && id.startsWith('.')) {
            id = path.join(path.dirname(importer), id)
          } else {
            return null
          }
        }

        return `${id}?${type}`
      }
    },
    async load(id) {
      let match

      if ((match = id.match(SUFFIX_RE))) {
        let [, id, _type] = match
        if (/[\?\0]/.test(id)) return null

        const type = _type as ASSET_TYPE

        return assetToStringifyUrl(this, path.normalize(id), type)
      } else if (id.startsWith(NATIVE_PREFIX) && id.endsWith(NATIVE_SUFFIX)) {
        id = id.slice(NATIVE_PREFIX.length)
        id = id.slice(0, id.length - NATIVE_SUFFIX.length)

        const url = await generateStringifyNativeUrl(this, id)

        return `
          let mod;
          Object.defineProperty(module, 'exports', {
            enumerable: true,
            get: () => {
              return mod ?? (mod = __NODE__REQUIRE__(${url}));
            }
          })
        `
      } else {
        id = path.normalize(id)

        if (path.isAbsolute(id)) {
          const isPublic = publicPathRe && id.match(publicPathRe)?.[0]
          const isAsset = filter(id)

          if (isAsset) {
            if (isPublic) {
              return `export default ${publicAssetsToStringifyUrl(this, id, isPublic)}`
            }
            const assetIdStringify = JSON.stringify(`${id}?${(await inline(id)) ? 'inline' : 'url'}`)
            return `export { default } from ${assetIdStringify};`
          } else if (isPreprocessorType(id) && existsSync(id)) {
            const targetUrl = await generateStringifyAssetUrl(this, id, true)
            return `export * from '${targetUrl}';
            import * as mod from '${targetUrl}';
            export default /*#__PURE__*/ ( mod && typeof mod === 'object' && 'default' in mod ? mod.default : mod );`
          }
        }
      }
    },
    renderChunk(code, chunk) {
      let s: MagicString
      let hasReplaced = false

      if (native) {
        for (const match of code.matchAll(NODE_REQUIRE__GLOBAL)) {
          s = s! ?? new MagicString(code)
          const start = match.index ?? 0
          const end = start + match[0].length

          s!.overwrite(start, end, 'require')
          hasReplaced = true
        }
      }

      for (const match of code.matchAll(ASSET_RE__GLOBAL)) {
        s = s! ?? new MagicString(code)
        const start = match.index
        if (!start) continue

        const end = start + match[0].length
        const { groups } = match
        if (groups) {
          const { nostring, ref, base64_fileName } = groups
          let fileName: string | undefined
          if (ref) {
            fileName = this.getFileName(ref)
          } else if (base64_fileName) {
            fileName = Buffer.from(base64_fileName, 'base64').toString()
          }

          if (!fileName) continue

          let replacement
          if (nostring) {
            replacement = JSON.stringify('./' + path.posix.relative(path.posix.dirname(chunk.fileName), fileName))
            replacement = replacement.slice(1, replacement.length - 1)
          } else {
            replacement = resolveFileUrl(fileName, chunk.fileName)
          }

          s!.overwrite(start, end, replacement)
          hasReplaced = true
        }
      }

      if (!hasReplaced) {
        return null
      }

      return { code: s!.toString(), map: sourcemap ? s!.generateMap({ hires: true }) : undefined }
    }
  }

  if (resolveFileNameExpression) {
    plugin.transform = async function (code, id) {
      let s: MagicString
      let hasReplaced = false

      for (const match of code.matchAll(DYNAMIC_FILE_URL_RE__GLOBAL)) {
        s = s! ?? new MagicString(code)
        const start = match.index ?? 0
        const end = start + match[0].length
        const { groups } = match

        if (groups) {
          const { d0, d1, e0, e1, f, f0, f1 } = groups

          try {
            let fileName: string | undefined

            if (d0) {
              fileName = eval(JSON.stringify(d0 === '__dirname' ? path.dirname(id) : id) + d1)
            } else if (e0) {
              fileName = eval(JSON.stringify(e0 === '__dirname' ? path.dirname(id) : id) + '+' + JSON.stringify(e1))
            } else if (f0) {
              const args = [
                f0 === '__dirname' ? path.dirname(id) : id,
                ...f1
                  .replace(/^\s*,\s*/, '')
                  .split(/\s*,\s*/)
                  .map(eval)
              ]
              fileName = (path as any)[f](...args)
            }

            if (fileName && typeof fileName === 'string') {
              const assetUrl = await generateStringifyAssetUrl(this, path.normalize(fileName))
              if (assetUrl) {
                s.overwrite(start, end, `(${assetUrl})`)
                hasReplaced = true
              }
            }
          } catch (e) {}
        }
      }

      for (const match of code.matchAll(REQUIRE_RESOLVE_RE__GLOBAL)) {
        const start = match.index ?? 0
        const end = start + match[0].length
        const { groups } = match

        if (groups) {
          const { c, m } = groups

          try {
            let fileName: string | undefined = eval(c)

            if (fileName && typeof fileName === 'string') {
              if (/^\.[\/\\\.]/.test(fileName)) {
                fileName = path.join(path.dirname(id), fileName)
              } else if (!path.isAbsolute(fileName)) {
                continue
              }

              const assetUrl = await generateStringifyAssetUrl(this, path.normalize(fileName))
              if (assetUrl) {
                s!.overwrite(start, end, `${m}(${assetUrl})`)
                hasReplaced = true
              }
            }
          } catch (e) {}
        }
      }

      if (!hasReplaced) {
        return null
      }

      return { code: s!.toString(), map: sourcemap ? s!.generateMap({ hires: true }) : undefined }
    }
  }

  return plugin
}
